Coding妙妙屋

软件漫谈:从底层实现到架构设计

0%

Python pyperf module

一、概述

在实际编码过程中常常需要对系统的性能进行剖析和优化,有以下工具可以使用:

perf是一款linux性能分析工具,它提供了一个性能分析框架,可以用于分析程序运行过程中发生的硬件时间(CPU的时钟周期)/软件时间(进程切换,缺页错误)来定位程序的性能瓶颈。

pyperf则是python中用于性能测试、分析的工具包。本文主要介绍如何快速上手pyperf,对指定代码块或模块进行简单的性能测试。

二、Quick Start

2.1 Install

执行命令行安装:

python3 -m pip install pyperf

若提示依赖的版本较低,可以尝试执行setuptools:

python3 -m pip install -U setuptools

2.2 Command line

  • 通过命令行可以执行代码块并进行简单的时间性能测量(benchmark),基本的测量方法为循环执行取均值。先看一个简单例子:
1
2
3
$ python3 -m pyperf timeit '[1,2]*1000'
.....................
Mean +- std dev: 4.19 us +- 0.05 us
  • 使用--output-o输出结果到 JSON 文件中:
1
2
3
$ python3 -m pyperf timeit '[1,2]*1000' -o bench.json
.....................
Mean +- std dev: 4.22 us +- 0.08 us
  • 使用stats指令计算多次执行的统计数据(均值/最大/最小值等):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$ python3 -m pyperf stats outliers.json -q
Total duration: 11.6 sec
Start date: 2017-03-16 16:30:01
End date: 2017-03-16 16:30:16
Raw value minimum: 135 ms
Raw value maximum: 197 ms

Number of calibration run: 1
Number of run with values: 20
Total number of run: 21

Number of warmup per run: 1
Number of value per run: 3
Loop iterations per value: 2^15
Total number of values: 60

Minimum: 4.12 us
Median +- MAD: 4.25 us +- 0.05 us
Mean +- std dev: 4.34 us +- 0.31 us
Maximum: 6.02 us

0th percentile: 4.12 us (-5% of the mean) -- minimum
5th percentile: 4.15 us (-4% of the mean)
25th percentile: 4.21 us (-3% of the mean) -- Q1
50th percentile: 4.25 us (-2% of the mean) -- median
75th percentile: 4.30 us (-1% of the mean) -- Q3
95th percentile: 4.84 us (+12% of the mean)
100th percentile: 6.02 us (+39% of the mean) -- maximum

Number of outlier (out of 4.07 us..4.44 us): 9
  • 使用直方图(hist指令)显示上述结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ python3 -m pyperf hist outliers.json -q
4.10 us: 15 ##############################
4.20 us: 29 ##########################################################
4.30 us: 6 ############
4.40 us: 3 ######
4.50 us: 2 ####
4.60 us: 1 ##
4.70 us: 0 |
4.80 us: 1 ##
4.90 us: 0 |
5.00 us: 0 |
5.10 us: 0 |
5.20 us: 2 ####
5.30 us: 0 |
5.40 us: 0 |
5.50 us: 0 |
5.60 us: 0 |
5.70 us: 0 |
5.80 us: 0 |
5.90 us: 0 |
6.00 us: 1 ##
  • 对于一些测试可能无法获得稳定的结果,此时可以尝试使用中位数median absolute deviation (MAD) 去进行数据统计。

更多高级统计方法可见:https://pyperf.readthedocs.io/en/latest/analyze.html

2.3 Python API

2.3.1 bench_func()

bench_func()用于对函数进行性能测试,主要包括如下参数:

  • name: 本次benchmark的名称,在脚本中应保持唯一;
  • func: 回调函数;
  • inner_loop: 用于标准化每次循环迭代的时间。

bench_func()方法在设计时具有不可忽略的执行开销,如果func本身时间较短(<1ms)则不建议使用该方法,此时使用timeit() bench_time_func()更加合适。下面给出一个用例,使用bench_func() 测量1ms的睡眠经过的时间:

1
2
3
4
5
6
7
8
9
10
11
#!/usr/bin/env python3
import pyperf
import time


def func():
time.sleep(0.001)


runner = pyperf.Runner()
runner.bench_func('sleep', func)

2.3.2 timeit()

timeit()同样用于性能测试,主要包括以下参数:

  • name: 本次benchmark的名称,在脚本中应保持唯一;
  • stmt: 执行的python语句;
  • setup: 在每次进行性能统计循环前执行的Python语句;
  • teardown: 在每次进行性能统计循环后执行的Python语句;
  • duplicate: stmt中语句的重复次数,减少外循环的性能成本。

下面给出一个例子,使用timeit() 测量1000个数字的排序列表时间性能:

1
2
3
4
5
6
7
#!/usr/bin/env python3
import pyperf

runner = pyperf.Runner()
runner.timeit("sorted(list(range(1000)), key=lambda x: x)",
stmt="sorted(s, key=f)",
setup="f = lambda x: x; s = list(range(1000))")

2.3.3 bench_command()

使用time.perf_counter()计时器对命令的执行时间进行基准测试,测量Wall-time而不是CPU-time

两者的区别为:

  • Wall-time: 计算开始到计算结束的时间;
  • CPU-time: CPU用来执行程序的时间;

部分程序由于需要线程等待IO完成用户输入,导致CPU无法100%被利用,所以通常CPU-timewall-time小。多线程时程序的CPU-time 是各个线程的CPU-time 时间统计之和。

关键参数如下:

  • name: 本次benchmark的名称,在脚本中应保持唯一;
  • command: 命令参数列表,第一个参数通常是程序,这里我们一般就使用sys.executable
1
2
3
4
5
6
#!/usr/bin/env python3
import sys
import pyperf

runner = pyperf.Runner()
runner.bench_command('python_startup', [sys.executable, '-c', 'pass'])

2.3.4 bench_time_func()

bench_time_func()与其他方法的差异是要求我们自己编写测量函数,返回所有循环经过的总时间。

  • name: 本次lbenchmark的名称,在脚本中应保持唯一;
  • time_func(loops, *args): 时间统计函数,需要我们基于loops参数内循环测量时间并返回。

下面给出一个例子,测量dict[key]的查询性能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/usr/bin/env python3
import pyperf


def bench_dict(loops, mydict):
range_it = range(loops)
t0 = pyperf.perf_counter()

for loops in range_it:
mydict['0']
mydict['100']
mydict['200']
mydict['300']
mydict['400']
mydict['500']
mydict['600']
mydict['700']
mydict['800']
mydict['900']

return pyperf.perf_counter() - t0


runner = pyperf.Runner()
mydict = {str(k): k for k in range(1000)}
# inner-loops: dict[str] is duplicated 10 times
runner.bench_time_func('dict[str]', bench_dict, mydict, inner_loops=10)

三、参考文献

pyperf API参考手册

pyperf 用户手册

Python Argument Clinic功能解析

一、Argument Clinic概述

在阅读cpython源码的过程中,常常能在模块上看到如下的注释语句:

1
2
3
4
5
6
7
8
/*[clinic input]
module _pickle
class _pickle.Pickler "PicklerObject *" "&Pickler_Type"
class _pickle.PicklerMemoProxy "PicklerMemoProxyObject *" "&PicklerMemoProxyType"
class _pickle.Unpickler "UnpicklerObject *" "&Unpickler_Type"
class _pickle.UnpicklerMemoProxy "UnpicklerMemoProxyObject *" "&UnpicklerMemoProxyType"
[clinic start generated code]*/
/*[clinic end generated code: output=da39a3ee5e6b4b0d input=4b3e113468a58e6c]*/

clinic是什么?本质是其实就是一个python脚本。

clinic有什么功能?自动生成cpython中参数解析功能的相关代码。

clinic是cpython中c文件的预处理器,可以通过固定格式的模板为builtins模块自动生成参数解析代码。如果自己维护cpython的参数解析代码,是一项较为繁琐的工作,需要在大量的地方维护冗余信息。当使用Argument Clinic功能时,我们不再需要自己进行参数解析,基于Argument Clinic生成的参数解析代码可以作为一个黑盒使用。

当前cpython中大部分参数解析的函数都使用了Argument Clinic模板自动生成功能,本文主要为所有打算编写自定义模块维护现有builtins 的同学提供基础指导。

二、基本语法

2.1 clinic input和output

clinic可以扫描文件中的指定行作为关键字,clinic input行之间的所有内容作为Clinic的模板输入,通常被称为Clinic block,也是我们需要重点关注和修改的部分。

  • clinic input start/*[clinic input]
  • clinic input end: [clinic start generated code]*/

构建python后执行命令行:./python .\Tools\clinic\clinic.py foo.c

可以扫描foo.c文件中的所有clinic block并生成代码,并在最后加上/*[clinic end generated code: output=xx input=xx]*/作为校验行,用于验证输入输出的对应关系。下面给出一个简单的例子:

1
2
3
4
5
6
7
8
/* foo.c */
/*[clinic input]

... clinic input

[clinic start generated code]*/
... clinic output
/*[clinic end generated code: output=da39a3ee5e6b4b0d input=bb0565424c99751c]*/

2.2 创建clinic模板

本节使用python3.10的_pickle.c模块作为样例,解析clinic的模板格式。

  1. 首先需要在类顶部声明模块/类定义,类似于C语言常常在文件顶部进行声明。此处应对所有模块与类进行声明,其名称应该与Python界面的名称保持一致,可以使用PyModuleDefPyTypeObject中定义的名称。

    _pickle中模块及类的clinic定义样例:

    1
    2
    3
    4
    /*[clinic input]
    module _pickle
    class _pickle.Pickler "PicklerObject *" "&Pickler_Type"
    [clinic start generated code]*/
  2. 创建函数块的clinic,应该由几部分组成:

    • 模块.类.方法名称(与python保持一致),(可选) 使用->在method后添加返回值类型;

    • 空行后写入参数名称及类型,每个参数都应占独立一行;

    • (可选) 为参数设置默认值,格式为name_of_parameter: converter = default_value

      converter是什么?

      我们需要了解参数应该被转换成什么类型,通常使用单个字符来表示某个特定类型,例如’O’表示对象,'s’表示字符串,'i’表示int型参数;详细可以参考:arg-parsing

    • (可选) 新增一行后缩进,为每个参数添加文档说明;

    • (可选) 若使用PyArg_ParseTuple() 解析参数,则所有参数都是位置相关,在最后加上/标记即可。如果需要使用关键字去解析参数PyArg_ParseTupleAndKeywords() 则不需要加/

    • (可选) 空行后,支持写入方法说明文档;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /*[clinic input]

    _pickle.Pickler.dump

    obj: 'O'
    argument document(optional)
    /

    Write a pickled representation of the given object to the open file.
    [clinic start generated code]*/

2.3 clinc代码生成

这里给出一个_pickle模块的例子验证代码生成的功能,我们构建python后,执行命令行:

./python .\Tools\clinic\clinic.py .\Modules\_pickle.c

可以看到clinic其实就是一个生成代码的python脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 未使用clinic时的原始pickler_dump函数
static PyObject *
Pickler_dump(PicklerObject *self, PyObject *args)
{
// 定义了一个临时变量用于PyObject类型的参数解析
PyObject *obj;

/* Check whether the Pickler was initialized correctly (issue3664).
Developers often forget to call __init__() in their subclasses, which
would trigger a segfault without this check. */
if (self->write == NULL) {
PyErr_Format(PicklingError,
"Pickler.__init__() was not called by %s.__init__()",
Py_TYPE(self)->tp_name);
return NULL;
}

// 使用PyArg_ParseTuple进行参数解析
if (!PyArg_ParseTuple(args, "O:dump", &obj))
return NULL;

if (_Pickler_ClearBuffer(self) < 0)
return NULL;

if (dump(self, obj) < 0)
return NULL;

if (_Pickler_FlushToFile(self) < 0)
return NULL;
Py_RETURN_NONE;
}

使用clinic处理后发生了几点变化:

  • 方法名根据clinic中定义的发生了改变,按模块/类/方法的格式进行定义_pickle_Pickler_dump;
  • 除了固定的self参数,其余参数根据clinic定义自动生成,此处自动生成了PyObject *obj
  • 参数不再需要传递PyObject *args,再使用PyArg_ParseTuple一个一个解析,解析的过程将自动完成;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/*[clinic input]

_pickle.Pickler.dump

obj: 'O'
/

Write a pickled representation of the given object to the open file.
[clinic start generated code]*/

static PyObject *
_pickle_Pickler_dump(PicklerObject *self, PyObject *obj)
/*[clinic end generated code: output=87ecad1261e02ac7 input=199cc5a0e7561167]*/
{
/* Check whether the Pickler was initialized correctly (issue3664).
Developers often forget to call __init__() in their subclasses, which
would trigger a segfault without this check. */
if (self->write == NULL) {
PickleState *st = _Pickle_GetGlobalState();
PyErr_Format(st->PicklingError,
"Pickler.__init__() was not called by %s.__init__()",
Py_TYPE(self)->tp_name);
return NULL;
}

if (_Pickler_ClearBuffer(self) < 0)
return NULL;

if (dump(self, obj) < 0)
return NULL;

if (_Pickler_FlushToFile(self) < 0)
return NULL;

Py_RETURN_NONE;
}

// _pickle.h 头文件对应的宏定义也会自动生成以下代码块
PyDoc_STRVAR(_pickle_Pickler_dump__doc__,
"dump($self, obj, /)\n"
"--\n"
"\n"
"Write a pickled representation of the given object to the open file.");

#define _PICKLE_PICKLER_DUMP_METHODDEF \
{"dump", (PyCFunction)_pickle_Pickler_dump, METH_O, _pickle_Pickler_dump__doc__},

// 直接添加到PyMethodDef中即可使用
static struct PyMethodDef Pickler_methods[] = {
_PICKLE_PICKLER_DUMP_METHODDEF
_PICKLE_PICKLER_CLEAR_MEMO_METHODDEF
_PICKLE_PICKLER___SIZEOF___METHODDEF
{NULL, NULL} /* sentinel */
};

我们再添加一个int型参数来查看变化,可以看到:

  • 函数增加了一个int型参数,这和我们预想的一样;
  • 函数名变为_pickle_Pickler_dump_impl,clinic会在函数名后增加_impl后缀;
  • _pickle.h文件中新增了_pickle_Pickler_dump自动补全了参数解析的代码块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/*[clinic input]

_pickle.Pickler.dump

obj: object
num: 'i'
/

Write a pickled representation of the given object to the open file.
[clinic start generated code]*/

static PyObject *
_pickle_Pickler_dump_impl(PicklerObject *self, PyObject *obj, int num)
/*[clinic end generated code: output=e80bca9c5c5a35a2 input=c6c713f75bc38e80]*/
{
...code block
}

/* _pickle.h文件,参数解析方式自动选择了METH_FASTCALL,对应_pickle_Pickler_dump(PicklerObject *self, PyObject *const *args, Py_ssize_t nargs)的回调函数参数类型
*/
#define _PICKLE_PICKLER_DUMP_METHODDEF \
{"dump", (PyCFunction)(void(*)(void))_pickle_Pickler_dump, METH_FASTCALL, _pickle_Pickler_dump__doc__},

static PyObject *
_pickle_Pickler_dump_impl(PicklerObject *self, PyObject *obj, int num);

/* 自动补充了参数解析的功能,我们写的函数逻辑作为底层被调用 */
static PyObject *
_pickle_Pickler_dump(PicklerObject *self, PyObject *const *args, Py_ssize_t nargs)
{
PyObject *return_value = NULL;
PyObject *obj;
int num;

if (!_PyArg_CheckPositional("dump", nargs, 2, 2)) {
goto exit;
}
obj = args[0];
num = _PyLong_AsInt(args[1]);
if (num == -1 && PyErr_Occurred()) {
goto exit;
}
// 执行业务逻辑,不需要考虑参数解析
return_value = _pickle_Pickler_dump_impl(self, obj, num);

exit:
return return_value;
}

可以看到使用clinic后不管是多少参数,都可以自动生成参数解析代码块,用声明式编程取代传统的命令式编程,使得我们的编码过程更加简单。

三、参考文章

除此了基本的功能之外,clinic还提供了强大的高级特性用于通过模板生成各种类型的函数,详细可见如下文档:

Argument Clinic官方文档

参数解析API相关文档

一、Python GIL概述

在Python中很重要的一个概念就是GIL(全局解释器锁),一个阻碍机器码并行执行的全局锁,这也代表了Python中的多线程实质上都是伪多线程,同一时刻实际只有一个线程在执行,即便使用多线程也无法做到真正的并发。

由于GIL的存在,在多核系统中执行CPU密集型任务时也无法利用多核优势,使用多线程的性能甚至会比单线程更差一些。这个问题业内通用的做法是使用多进程编程,除此之外本文还将介绍Python中协程的使用(某些场景比线程效率更优的任务调度方式)。

二、并发编程

2.1 多进程与多线程

multiprocessing 是官方提供的多进程管理包,通过使用子进程而非线程有效地绕过了全局解释器锁。 因此,multiprocessing 模块允许程序员充分利用给定机器上的多个处理器。

先看一组多线程与多进程执行独立任务时的性能对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# @Time    : 2021/12/26 22:43 
# @Author : CharlieZhao
# @File : test_multiprocessing.py
# @Software: PyCharm
# @Note : This module is used to test performance of multiprocessing

""" To avoid performance deficiency caused by GIL, multiprocessing is a better way to
perform concurrent tasks than threading. This module is used to test performance between
multiprocessing and threading.
"""

from multiprocessing import Process
from threading import Thread
import time


def _print_number(info):
res = 0
for i in range(100_000_000):
res = res + i
print(info + " task over, result = {}".format(res))


def show_execution_time(start_time, end_time):
print("The execution time is {} s".format(end_time - start_time))


def execute_multiprocessing_task():
"""
log the execution time of multiprocessing task.
:return: void
"""
print(" multiprocessing task start ")
p1 = Process(target=_print_number, args=("Process 1",))
p2 = Process(target=_print_number, args=("Process 2",))

start = time.time()
p1.start()
p2.start()
p1.join()
p2.join()
end = time.time()

show_execution_time(start, end)
print(" multiprocessing task end ")


def execute_threading_task():
print(" threading task start ")
t1 = Thread(target=_print_number, args=("Thread 1",))
t2 = Thread(target=_print_number, args=("Thread 2",))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

show_execution_time(start, end)
print(" threading task end ")


if __name__ == "__main__":
execute_multiprocessing_task()
execute_threading_task()
pass

"""
result:

multiprocessing task start
Process 2 task over, result = 4999999950000000
Process 1 task over, result = 4999999950000000
The execution time is 10.988635778427124 s
multiprocessing task end

threading task start
Thread 1 task over, result = 4999999950000000
Thread 2 task over, result = 4999999950000000
The execution time is 19.204389572143555 s
threading task end

可以很直观的看到在执行两个CPU密集型的独立任务时,使用多进程相较于使用多线程效率快了一倍以上。
当然,Python多线程也不是一无是处,在执行IO密集型任务时,单单使用多进程就不太适合了。
使用多进程+多线程的模式可以绕过阻塞线程,同时相较于单进程+多线程一定程度上减少了CPU线程切换的性能损失。
"""

2.2 协程基础应用

协程是一种用户级的轻量级线程,协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。

再对比一下线程和协程:

多线程:多线程的本质是抢占式多任务调度。不管是进程还是线程,每次阻塞、切换都需要陷入系统调用(system call),先让CPU跑操作系统的调度程序,然后再由调度程序决定该跑哪一个进程(线程)。

协程:协程的本质是协作式多任务调度,需要用户自己来编写调度逻辑。对于CPU来说,协程其实就是单线程,不需要考虑怎么调度、切换上下文,一定程度避免了CPU调度线程切换的性能损失。

但实际用好协程其实是非常困难的,不管是Debug还是由自己手动维护状态转移、调度逻辑都有很大的挑战,在此给出一个协程的使用样例,后续再继续深入讨论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# @Time    : 2021/12/29 22:41 
# @Author : CharlieZhao
# @File : test_asyncio.py
# @Software: PyCharm

"""async task example"""

import threading
import asyncio
import time


def show_execution_time(start_time, end_time):
print("The execution time is {} s".format(end_time - start_time))


async def _async_print_number(info):
res = 0
for i in range(100_000_000):
res = res + i
# 增加一条线程打印语句观测调用过程
print(info, " res={} in {}".format(res, threading.currentThread()))
await asyncio.sleep(1) # 1s sleep 模拟IO等待,在等待时自动切换任务
print(info + " task over, result = {}".format(res))


def execute_async_task():
loop = asyncio.get_event_loop()
# 创建两个任务
tasks = [_async_print_number("async task 1:"), _async_print_number("async task 2:")]
print(" async task start ")

start = time.time()
loop.run_until_complete(asyncio.wait(tasks))
end = time.time()

show_execution_time(start, end)
print(" async task end ")


if __name__ == "__main__":
execute_async_task()

"""
result: 可以看到两个任务1s为周期交替执行(实质是一个任务进入IO阻塞后,通过我们的协程调度逻辑,
在Event_loop中切换至另一个可用任务),且两个任务都在主线程中执行没有线程的切换。
async task start
async task 2: res=0 in <_MainThread(MainThread, started 21168)>
async task 1: res=0 in <_MainThread(MainThread, started 21168)>
async task 2: res=1 in <_MainThread(MainThread, started 21168)>
async task 1: res=1 in <_MainThread(MainThread, started 21168)>
async task 2: res=3 in <_MainThread(MainThread, started 21168)>
async task 1: res=3 in <_MainThread(MainThread, started 21168)>
async task 2: res=6 in <_MainThread(MainThread, started 21168)>
async task 1: res=6 in <_MainThread(MainThread, started 21168)>
async task 2: res=10 in <_MainThread(MainThread, started 21168)>
async task 1: res=10 in <_MainThread(MainThread, started 21168)>
async task 2: res=15 in <_MainThread(MainThread, started 21168)>
async task 1: res=15 in <_MainThread(MainThread, started 21168)>
"""

2.3 样例代码

⭐️GitHub: Python GIL与并发编程基础,一键三连!